web

스크롤 이벤트 최적화

10 min read|19. 2. 15.

⚠ 이 포스팅은 자바스크립트의 비동기 처리에 대한 이해를 기반으로 작성되었습니다. 이 포스팅을 읽기 전, JavaScript의 Event Loop자바스크립트 비동기 처리 과정 일독을 추천 드립니다.

우리는 아래처럼 브라우저의 스크롤에 이벤트를 추가할 수 있습니다.

window.addEventListener('scroll', onScroll)

onScroll 함수에 console.log('scrolled') 를 추가해보겠습니다.

onScroll 함수가 마구 실행됩니다. 위 예시에서는 console을 찍는 function을 추가해줬지만 조금 무거운 이벤트를 등록하면 어떻게 될까요? 😥 scroll event에 reflow가 여러번 발생하는 무거운 callback function을 등록한 예시 코드 입니다.

TL;DR

  • If possible, don't use scroll event.
  • Use requestAnimationFrame.
  • Use { passive: true } option of event listener.

Not throttle, But requestAnimationFrame

throttle?

throttle 이라는 function을 사용해서 스크롤 이벤트가 트리거되는 **'정도'**를 조절할 수 있습니다. 다음 코드는 300ms마다 onScroll이 호출되도록 scroll 이벤트를 등록한 코드입니다.

window.addEventListener('scroll', throttle(onScroll, 300))

대부분의 경우 이 정도로 최적화가 가능합니다. 하지만 아쉽게도 이 방법은 우리가 기대한대로 동작하지 않을 수 있습니다. 이 throttle function은 debounce를 기반으로 동작하며, 이 debounce는 setTimeout 기반으로 동작합니다. 이 setTimeout 이 기대한대로 동작하지 않을 수 있기 때문입니다. (각 링크는 VanillaJS 구현 코드입니다.)

:gift: throttle과 debounce의 개념이 명확하지 않다면 Throttle vs Debounce Demo에서 확인해보실 수 있습니다.

싱글 스레드로 동작하는 JavaScript는 setTimeout API의 비동기 task들을 **Task Queue(a.k.a. macro queue)**에 넣어둔 후 순차적으로 처리합니다. Queue에 저장된 비동기 task를 처리하는 시점은 Call stack이 비어져있을 경우입니다. 이 시점이 setTimeout 또는 setInterval에 할당해준 delay와 맞지 않는다면 등록해둔 callback은 trigger 되지 않을 수 있습니다.

:gift: 보다 자세한 내용은 해당 포스팅 하단의 reference를 확인해주세요.

requestAnimationFrame? (rAF)

우리는 브라우저가 렌더링 할 수 있는 **'능력'**에 맞춰 이벤트를 trigger를 트리거해줄 수 있습니다. 즉 일부러 300ms 씩 trigger 하려고 하지 않아도 되는 것입니다. 브라우저는 60fps(초당 60회)로 화면을 렌더링합니다. 이 렌더링에 최적화하기 위해 rAF 이라는 API를 사용할 수 있습니다.

rAF API도 setTimeout 과 마찬가지로 callback으로 넘겨지는 function을 비동기 task로 분류하여 처리합니다. 다만 rAFmacro queue가 아니라 animation frame에서 처리됩니다. 또한 setTimeout 두번째 parameter로 전달되는 delay 값이 브라우저 렌더링에 최적화되어 있다는 차이가 있습니다.

이 블로그 템플릿에서는 스크롤 이벤트를 최적화하기 위해 toFit 이라는 util function을 만들어서 사용했습니다.

export function toFit(
  cb,
  { dismissCondition = () => false, triggerCondition = () => true }
) {
  if (!cb) {
    throw Error('Invalid required arguments')
  }

  let tick = false

  return function() {
    console.log('scroll call')

    if (tick) {
      return
    }

    tick = true
    return requestAnimationFrame(() => {
      if (dismissCondition()) {
        tick = false
        return
      }

      if (triggerCondition()) {
        console.log('real call')
        tick = false
        return cb()
      }
    })
  }
}

해당 템플릿에서는 스크롤 시, 현재 스크롤의 위치와 bottom값의 차이가 일정 미만일 때만 트리거 해주는 이벤트를 등록하기 위해 사용했습니다. 다음과 같은 방식으로 사용할 수 있습니다.

window.addEventListener('scroll', toFit(onScroll))

바로 onScroll을 이벤트에 등록하지 않고 toFit으로 한 번 감싸줬습니다. 지금부터 이 toFit util이 하는 일에 대해 알아보겠습니다.

What happened?

간소화 된 형태를 먼저 살펴보겠습니다.

export function toFitSimple(cb) {
  let tick = false

  return function trigger() {
    if (tick) {
      return
    }

    tick = true
    return requestAnimationFrame(function task() {
      tick = false
      return cb()
    })
  }
}

toFitSimple

  1. 스크롤 시 실제로 발생시킬 함수 cb를 받는다.
  2. trigger 함수를 반환한다.
  3. tick 변수는 이 trigger 함수에서 참조하는 변수이다. (클로져)
  4. trigger 함수는 tick의 값에 따라 다른 값을 반환한다.

tick이라는 flag 변수가 브라우저가 렌더링 할 수 있는 능력 이상의 cb 함수 호출을 막습니다.

Trigger scroll

스크롤을 발생시키면 어떤 일이 벌어지는지 순차적으로 보겠습니다.

  1. rAF의 callback으로 넘겨지는 task 함수가 animation frame에 들어간다.
  2. 실제로 실행되기 전 까지는 ticktrue이므로 trigger가 아무리 호출되도 아무것도 실행되지 않는다.
  3. task 함수가 event loop에 의해 실행된다. 3-1. 실행될 때 tick을 false로 바꿔주면서 실제 동작을 한다.
  4. 다시 1번으로 이동.

실제로 호출되어야 하는 cbrAF에 의해 비동기로 처리되고 tick에 의해 브라우저 렌더링 범위 내에서 animation frame에 들어가게 되므로 스크롤 이벤트를 최적화 할 수 있습니다.

toFit은 이 toFitSimple에 trigger 조건과 dismiss 조건을 추가한 함수일 뿐 입니다.

Passive Event

브라우저는 기본적으로 이 preventDefault 를 호출하는지 호출하지 않는지를 감시하게 되는데요, 스크롤 이벤트를 호출할 경우에는 event 객체의 preventDefault를 호출하지 않기 때문에 이 비용을 절감할 수 있습니다. 이 때, passive 속성을 통해 preventDefault API를 호출하지 않음을 명시할 수 있습니다. 즉, passive 속성을 true 로 지정해줄 경우, event.preventDefault 가 호출되는지에 대한 감시 비용을 줄일 수 있습니다.

폴리필 구현을 통해 내부 구현 원리 살펴보기 (LINK)

DOM Element에 이벤트를 등록할 때 사용하는 API인 addEventListener 의 세번째 인자로 이 속성을 전달합니다.

window.addEventListener('scroll', onScroll, { passive: true })

이 때, 브라우저에서 passive 속성을 지원하는지에 대한 판단이 필요하게 됩니다. 따라서 아래 코드가 추가됩니다.

// if they are supported, setup the optional params
// IMPORTANT: FALSE doubles as the default CAPTURE value!
const passiveEvent = passiveEventSupported
  ? { capture: false, passive: true }
  : false
window.addEventListener('scroll', onScroll, passiveEvent)

https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md#feature-detection

{ passive: true } 는 스크롤 이벤트 뿐만 아니라 touchstart, touchendpreventDefault 호출이 필요없는 이벤트를 등록할 때 주로 사용됩니다.

touch-* 이벤트들은 passive 속성의 default 값이 true입니다. (관련 링크)

:gift: passive 속성 브라우저 커버리지 링크: Can I use 'passive' property?

Conclusion

window.addEventListener(
  'scroll',
  toFit(onScroll, {
    // triggerCondition:
    // dismissCondition:
  }),
  { passive: true }
)

최종적으로 이런 형태로 스크롤 이벤트를 등록해줬습니다.

Reference

이 글을 작성하면서 좀 더 정확한 표현, 내용을 담기 위해 참고한 문서들입니다.

  • requestAnimationFrame으로 비동기 처리를 했을 경우 사용하는 animation frame에 관련된 spec 문서
    • https://html.spec.whatwg.org/multipage/imagebitmap-and-animations.html#animation-frames
  • jQuery creator, 존 레식이 작성한 JavaScript의 timer 동작 원리
    • https://johnresig.com/blog/how-javascript-timers-work/
  • JavaScript의 asynchronous task 처리 과정
    • https://blog.risingstack.com/writing-a-javascript-framework-execution-timing-beyond-settimeout/
  • requestAnimationFrame을 사용한 성능 개선
    • https://dev.opera.com/articles/better-performance-with-requestanimationframe/
  • Google Developers Web Fundamentals - Performance part
    • https://developers.google.com/web/fundamentals/performance/rendering/debounce-your-input-handlers
    • https://developers.google.com/web/tools/lighthouse/audits/passive-event-listeners
    • https://developers.google.com/web/fundamentals/performance/rendering/optimize-javascript-execution#web_workers